Micron Document
🎖️GitЯра🎖️

Commit e3e09452ddbfaba3f67b8df18394afb681569307


Parents : 94f6301
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-06-03T06:36:09-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-06-03T06:36:09-05:00

fix(map): render cluster markers in-scope to kill ClusterRenderer FATAL (#5723)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Changes
Diff

diff --git a/.agent_memory/session_context.archive.md b/.agent_memory/session_context.archive.md
index 90cf98f4c1..bda6af55ef 100644
--- a/.agent_memory/session_context.archive.md
+++ b/.agent_memory/session_context.archive.md
@@ -2,6 +2,13 @@
# Older handover entries rotated out of session_context.md. Not read by default.
# Consult only if you need historical detail on a specific past change.
+## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
+- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
+- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
+- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
+- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
+- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
+
## 2026-05-20 — Decoupled and Isolated Flatpak manifest generation logic to build-logic/flatpak
- Isolated the optimized `GenerateFlatpakSourcesTask` from monolithic `build-logic/convention` into its own specialized, lightweight `:flatpak` subproject under `build-logic`.
- Created `:flatpak` configuration and registered the formal plugin ID `"meshtastic.flatpak"` implemented by `FlatpakConventionPlugin` inside the default package namespace.

diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md
index 912c57e4e6..b558448f0f 100644
--- a/.agent_memory/session_context.md
+++ b/.agent_memory/session_context.md
@@ -6,6 +6,12 @@
# the oldest entries to `session_context.archive.md` (not read by default). The
# "Golden Context" block at the bottom is stable across sessions; keep it here.
+## 2026-06-03 — Cluster-marker FATAL: revert shipped map series + in-scope rememberComposeBitmapDescriptor fix
+- Reverted ALL google-flavor map changes to before #5684 (per user): restored MapView.kt, NodeClusterMarkers.kt, WaypointMarkers.kt, InlineMap.kt to parent commit bc9f1637; deleted MarkerBitmapRenderer.kt; re-pinned `play-services-maps = 20.0.0` in libs.versions.toml. The shipped #5702–#5719 series (Canvas markers + ViewTree-owner band-aids) had lost the info-window popups + interactions.
+- Root cause (verified against maps-compose 8.3.0 + android-maps-utils 4.1.1 SOURCE in gradle cache): ONLY `Clustering(clusterItemContent=…)` crashes — its `ComposeUiClusterRenderer` builds a *detached* `InvalidatingComposeView` with a fake lifecycle owner and NO SavedStateRegistryOwner. `MarkerComposable` already bakes its icon via the safe in-scope `rememberComposeBitmapDescriptor`; info windows render with the live marker compositionContext. So InlineMap/NodeTrack/Traceroute were left untouched.
+- Fix (NodeClusterMarkers.kt ONLY): icons baked in-scope via `rememberComposeBitmapDescriptor(node){ PulsingNodeChip }` into a snapshot stateMap; custom `private class NodeClusterRenderer : DefaultClusterRenderer` assigns them in onBeforeClusterItemRendered/onClusterItemUpdated (bg thread, READ-only — never composes, so the crash class is gone). Native info windows (super sets title/snippet) + onClusterItemInfoWindowClick→navigateToNodeDetails; precision circles drawn from the renderer's own `unclusteredItems` MutableState (clusterItemDecoration can't fire — `ClusterRendererItemState` is lib-internal). Strictly better than the elegant-euler Canvas branch — keeps the REAL Compose chip.
+- `compileGoogleDebugKotlin` + `spotlessCheck` + `detekt` PASS. NOT committed, NOT device-verified. Next: device-test (clusters show chips + info-window popups + no FATAL), then commit/push.
+
## 2026-05-28 — Stabilized DatabaseManager withDb retry host test
- Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`.
- Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping.
@@ -33,13 +39,6 @@
- Refactored `GeminiNanoDocAssistant.answer` to reuse `answerStream` flow under the hood, eliminating duplicate prompting code.
- Verified that all unit tests (`:feature:docs:allTests`) and static analysis checks (`spotlessApply spotlessCheck detekt`) pass 100% green.
-## 2026-05-21 — Fixed Chirpy Assistant invalid model name and enhanced failure fallback suggestions
-- Fixed a 404/Unknown inference error by updating `GeminiNanoDocAssistant.kt`'s `MODEL_NAME` from `"gemini-3.1-flash-lite"` to the correct Firebase AI Logic preview name `"gemini-3.1-flash-lite-preview"`.
-- Overhauled multi-turn hybrid chat seeding: eliminated the redundant background `chat.sendMessage` call on the first turn; if the first turn is answered on-device, the session caches the Q&A locally and seeds the subsequent cloud-chat session via `startChat(history = ...)`.
-- Expanded the hybrid model's `looksLikeNoAnswer` heuristics to better detect on-device failure and fall back to the grounded cloud model.
-- Programmed a smart UI fallback: on inference error (offline, rate limit, model not found), Chirpy displays local keyword search results as recommended page chips.
-- Verified 100% compliance with Spotless, Detekt, and unit tests (`:feature:docs:allTests` and `:androidApp:testGoogleDebugUnitTest`).
-
## Golden Context (stable across sessions)
- Always check `.skills/compose-ui/strings-index.txt` before reading `strings.xml`.
- Run `python3 scripts/sort-strings.py` after adding strings to keep the index organized.

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 31ff21ade6..6ca71eb6a5 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
-import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
@@ -102,7 +102,6 @@ import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
-import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -126,6 +125,7 @@ import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
+import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -186,7 +186,6 @@ fun MapView(
mode: GoogleMapMode = GoogleMapMode.Main,
) {
val context = LocalContext.current
-
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
@@ -493,31 +492,7 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- // Resolve the selected custom tile provider once (cached). getTileProvider returns null when the
- // configured source is unusable (bad {x}/{y}/{z} URL template, missing local MBTiles file, etc.).
- val customTileConfigs by mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle()
- val customTileProvider =
- remember(currentCustomTileProviderUrl, customTileConfigs) {
- currentCustomTileProviderUrl?.let { url ->
- val config = customTileConfigs.find { it.urlTemplate == url || it.localUri == url }
- mapViewModel.getTileProvider(config)
- }
- }
-
- // Only blank the Google base map (MapType.NONE) when we actually have a working custom basemap to draw
- // over it. If the selected custom source failed to build, fall back to the user's base map instead of
- // rendering MapType.NONE with no tiles — that is a solid black screen with no recourse.
- val effectiveGoogleMapType = if (customTileProvider != null) MapType.NONE else selectedGoogleMapType
-
- // Surface the fallback so a broken custom tile source is diagnosable instead of a silent black map.
- LaunchedEffect(currentCustomTileProviderUrl, customTileProvider) {
- if (currentCustomTileProviderUrl != null && customTileProvider == null) {
- Logger.withTag("MapView").w {
- "Custom tile provider '$currentCustomTileProviderUrl' could not be built; " +
- "falling back to base map $selectedGoogleMapType"
- }
- }
- }
+ val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf<List<NodeClusterItem>?>(null) }
@@ -566,11 +541,16 @@ fun MapView(
}
},
) {
- // Custom tile overlay (all modes) — uses the hoisted provider so the base-map decision above and
- // this overlay stay consistent (no overlay ⇒ base map is shown, never a black MapType.NONE).
+ // Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
- customTileProvider?.let { tileProvider ->
- TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
+ currentCustomTileProviderUrl?.let { url ->
+ val config =
+ mapViewModel.customTileProviderConfigs.collectAsStateWithLifecycle().value.find {
+ it.urlTemplate == url || it.localUri == url
+ }
+ mapViewModel.getTileProvider(config)?.let { tileProvider ->
+ TileOverlay(tileProvider = tileProvider, fadeIn = true, transparency = 0f, zIndex = -1f)
+ }
}
}
@@ -884,17 +864,17 @@ private fun NodeTrackOverlay(
}
if (index == sortedPositions.lastIndex) {
- val chipIcon = rememberNodeChipDescriptor(focusedNode)
- Marker(
+ MarkerComposable(
state = markerState,
- icon = chipIcon,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
- )
+ ) {
+ NodeChip(node = focusedNode)
+ }
} else {
MarkerInfoWindowComposable(
state = markerState,
@@ -989,6 +969,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
// region --- Traceroute Map Content ---
+@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun TracerouteMapContent(
forwardOffsetPoints: List<LatLng>,
@@ -1017,8 +998,7 @@ private fun TracerouteMapContent(
}
displayNodes.forEach { node ->
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
- val chipIcon = rememberNodeChipDescriptor(node)
- Marker(state = markerState, icon = chipIcon, zIndex = 4f)
+ MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
}
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
deleted file mode 100644
index 60ff875959..0000000000
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/MarkerBitmapRenderer.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package org.meshtastic.app.map.component
-
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.graphics.RectF
-import android.graphics.Typeface
-import android.text.TextPaint
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.platform.LocalDensity
-import androidx.core.graphics.createBitmap
-import com.google.android.gms.maps.model.BitmapDescriptor
-import com.google.android.gms.maps.model.BitmapDescriptorFactory
-import org.meshtastic.core.model.Node
-
-private const val CHIP_CORNER_RADIUS_DP = 4f
-private const val CHIP_PADDING_HORIZONTAL_DP = 8f
-private const val CHIP_MIN_WIDTH_DP = 64f
-private const val CHIP_MIN_HEIGHT_DP = 28f
-private const val CHIP_TEXT_SIZE_SP = 14f
-private const val EMOJI_TEXT_SIZE_SP = 32f
-private const val EMOJI_PADDING_DP = 2f
-
-/**
- * Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
- * maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
- * measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
- */
-@Composable
-fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
- val density = LocalDensity.current.density
- val fontScale = LocalDensity.current.fontScale
- return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
- buildNodeChipDescriptor(node, density, fontScale)
- }
-}
-
-/**
- * Non-`@Composable` variant of [rememberNodeChipDescriptor] for callers that have no composition to read [LocalDensity]
- * from — specifically a [com.google.maps.android.clustering.view.DefaultClusterRenderer] building marker icons on its
- * background render thread. Keeping the cluster icon on this Canvas path (instead of maps-compose's
- * `clusterItemContent` Composable) avoids the off-screen ComposeView in `ComposeUiClusterRenderer`, which has no
- * reachable `ViewTreeLifecycleOwner` from the async render Handler and was our top FATAL
- * (googlemaps/android-maps-compose#325/#875).
- *
- * [BitmapDescriptorFactory] only works once the Maps SDK is initialized — which the SDK does when a GoogleMap creates
- * its MapView. Every descriptor is built for markers of an on-screen map (this cluster path runs via `MapEffect` on a
- * live map; the `@Composable` variants compose inside a `GoogleMap` content lambda), so the SDK is always ready by
- * then.
- */
-fun buildNodeChipDescriptor(node: Node, density: Float, fontScale: Float): BitmapDescriptor =
- renderNodeChipBitmap(node, density, fontScale)
-
-/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
-@Composable
-fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
- val density = LocalDensity.current.density
- val fontScale = LocalDensity.current.fontScale
- return remember(codePoint) { renderEmojiBitmap(codePoint, density, fontScale) }
-}
-
-private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
- val (textColorInt, nodeColorInt) = node.colors
- val scaledDensity = density * fontScale
-
- val textPaint =
- TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
- textSize = CHIP_TEXT_SIZE_SP * scaledDensity
- typeface = Typeface.DEFAULT_BOLD
- color = textColorInt
- textAlign = Paint.Align.CENTER
- isStrikeThruText = node.isIgnored
- }
- val label = node.user.short_name.ifEmpty { "???" }
-
- val textWidth = textPaint.measureText(label)
- val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
- val minWidth = CHIP_MIN_WIDTH_DP * density
- val minHeight = CHIP_MIN_HEIGHT_DP * density
-
- val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
- val height = minHeight.toInt()
-
- val bitmap = createBitmap(width, height)
- val canvas = Canvas(bitmap)
-
- val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
- val cornerRadius = CHIP_CORNER_RADIUS_DP * density
- canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
-
- val textX = width / 2f
- val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
- canvas.drawText(label, textX, textY, textPaint)
-
- return BitmapDescriptorFactory.fromBitmap(bitmap)
-}
-
-private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
- val scaledDensity = density * fontScale
- val textPaint =
- TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
- textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
- textAlign = Paint.Align.CENTER
- }
- val emoji = String(Character.toChars(codePoint))
- val padding = EMOJI_PADDING_DP * density
-
- val textWidth = textPaint.measureText(emoji)
- val metrics = textPaint.fontMetrics
- val textHeight = metrics.descent - metrics.ascent
-
- val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
- val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
-
- val bitmap = createBitmap(width, height)
- val canvas = Canvas(bitmap)
-
- val textX = width / 2f
- val textY = padding - metrics.ascent
- canvas.drawText(emoji, textX, textY, textPaint)
-
- return BitmapDescriptorFactory.fromBitmap(bitmap)
-}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
index 682984c1de..0e3f872be2 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/NodeClusterMarkers.kt
@@ -18,15 +18,17 @@ package org.meshtastic.app.map.component
import android.content.Context
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
import com.google.android.gms.maps.GoogleMap
+import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.Marker
import com.google.android.gms.maps.model.MarkerOptions
import com.google.maps.android.clustering.Cluster
@@ -37,17 +39,28 @@ import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.rememberClusterManager
+import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
private const val MIN_CLUSTER_SIZE = 10
-// Match the bottom-center anchor maps-compose used for the old `clusterItemContent` chip
-// (clusterItemContentAnchor defaults to Offset(0.5f, 1.0f)), so chips keep sitting on the node coordinate.
-private const val CHIP_ANCHOR_U = 0.5f
-private const val CHIP_ANCHOR_V = 1.0f
-
+/**
+ * Renders node markers with clustering.
+ *
+ * Marker bitmaps are generated **in the maps compose scope** via [rememberComposeBitmapDescriptor], which composes each
+ * chip in a `ComposeView` parented to the live host view (a real, attached view that has valid `ViewTreeLifecycleOwner`
+ * / `SavedStateRegistryOwner`) and renders it synchronously to a [BitmapDescriptor].
+ *
+ * This deliberately avoids the clustering library's `clusterItemContent` path: that renderer
+ * ([com.google.maps.android.compose.clustering.ComposeUiClusterRenderer]) composes each item in a *detached*
+ * `ComposeView` that only carries a fake lifecycle owner and no `SavedStateRegistryOwner`, so it crashes when the
+ * surrounding Navigation 3 / popup hierarchy lacks those owners (the top Crashlytics FATAL). A custom
+ * [DefaultClusterRenderer] subclass assigns the pre-baked bitmaps instead, keeping native info windows (title/snippet
+ * from [NodeClusterItem]) and click interactions intact.
+ */
@OptIn(MapsComposeExperimentalApi::class)
+@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
@@ -56,54 +69,58 @@ fun NodeClusterMarkers(
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val context = LocalContext.current
- val density = LocalDensity.current.density
- val fontScale = LocalDensity.current.fontScale
-
- val clusterManager = rememberClusterManager<NodeClusterItem>() ?: return
-
- // Render each non-clustered node as a Canvas-built BitmapDescriptor through a custom
- // DefaultClusterRenderer instead of passing a `clusterItemContent` Composable. The Composable path makes
- // maps-compose rasterize the chip through an off-screen ComposeView (ComposeUiClusterRenderer); that view
- // walks the view tree for a ViewTreeLifecycleOwner/SavedStateRegistryOwner and, because the cluster
- // renderer drives marker creation from an async Handler after the screen may have stopped, finds none and
- // crashes with "Composed into the View which doesn't propagate ViewTreeLifecycleOwner!"
- // (googlemaps/android-maps-compose#325 / #875) — historically our #1 FATAL. A renderer that paints the icon
- // in onBeforeClusterItemRendered never creates a View, so the crash class is eliminated rather than raced
- // against (see the owner-propagation workarounds in #5704/#5708 that could not win that race).
- val rendererState: MutableState<NodeChipClusterRenderer?> = remember { mutableStateOf(null) }
-
- MapEffect(clusterManager, density, fontScale) { map ->
- val renderer = NodeChipClusterRenderer(context, map, clusterManager, density, fontScale)
- renderer.minClusterSize = MIN_CLUSTER_SIZE
- clusterManager.renderer = renderer
- rendererState.value = renderer
- }
+ val clusterManager = rememberClusterManager<NodeClusterItem>()
- SideEffect {
- clusterManager.setOnClusterClickListener(onClusterClick)
- clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
+ // Bake each node's marker icon in-scope. Keyed by node so a bitmap is only re-rendered when that node
+ // actually changes. The descriptors are stashed in a snapshot map the renderer reads at render time.
+ val iconDescriptors = remember { mutableStateMapOf<Int, BitmapDescriptor>() }
+ nodeClusterItems.forEach { item ->
+ key(item.node.num) {
+ val descriptor = rememberComposeBitmapDescriptor(item.node) { PulsingNodeChip(node = item.node) }
+ DisposableEffect(descriptor) {
+ iconDescriptors[item.node.num] = descriptor
+ onDispose { iconDescriptors.remove(item.node.num) }
+ }
+ }
}
- Clustering(items = nodeClusterItems, clusterManager = clusterManager)
-
- // The library's `clusterItemDecoration` only fires for its internal ComposeUiClusterRenderer (the gating
- // ClusterRendererItemState type is library-internal), so it never runs for our custom renderer. Draw the
- // precision circles ourselves for exactly the unclustered items the renderer exposes, preserving the prior
- // "circle only on non-clustered nodes" behavior.
- val renderer = rendererState.value
- if (renderer != null && mapFilterState.showPrecisionCircle) {
- val unclusteredItems by renderer.unclusteredItems
- unclusteredItems.forEach { item ->
- item.getPrecisionMeters()?.let { precisionMeters ->
- if (precisionMeters > 0) {
- Circle(
- center = item.position,
- radius = precisionMeters,
- fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
- strokeColor = Color(item.node.colors.second),
- strokeWidth = 2f,
- zIndex = 0f,
- )
+ if (clusterManager != null) {
+ val rendererState = remember { mutableStateOf<NodeClusterRenderer?>(null) }
+
+ // The renderer needs the GoogleMap instance, only available inside the map scope.
+ MapEffect(clusterManager) { map ->
+ val renderer = NodeClusterRenderer(context, map, clusterManager) { iconDescriptors[it] }
+ clusterManager.renderer = renderer
+ rendererState.value = renderer
+ }
+
+ // Keep listeners current — the lambdas can change across recompositions.
+ SideEffect {
+ clusterManager.setOnClusterClickListener { cluster -> onClusterClick(cluster) }
+ clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
+ }
+
+ // Re-cluster once the renderer is attached and freshly-baked icons arrive, so markers pick up the bitmaps
+ // even when the cluster manager became available after the icons were rendered.
+ val renderer = rendererState.value
+ LaunchedEffect(renderer, iconDescriptors.size) { if (renderer != null) clusterManager.cluster() }
+
+ Clustering(items = nodeClusterItems, clusterManager = clusterManager)
+
+ // Precision circles for the currently-unclustered items (the renderer tracks them as the zoom changes).
+ if (mapFilterState.showPrecisionCircle) {
+ renderer?.unclusteredItems?.value?.forEach { item ->
+ item.getPrecisionMeters()?.let { precisionMeters ->
+ if (precisionMeters > 0) {
+ Circle(
+ center = item.position,
+ radius = precisionMeters,
+ fillColor = Color(item.node.colors.second).copy(alpha = 0.2f),
+ strokeColor = Color(item.node.colors.second),
+ strokeWidth = 2f,
+ zIndex = 0f,
+ )
+ }
}
}
}
@@ -111,37 +128,39 @@ fun NodeClusterMarkers(
}
/**
- * A [DefaultClusterRenderer] that draws each non-clustered node's chip as a Canvas [BitmapDescriptor]
- * ([buildNodeChipDescriptor]) instead of a Composable, avoiding maps-compose's crash-prone off-screen ComposeView
- * rasterization. It also exposes the current set of unclustered items so the caller can draw precision circles (the
- * library's `clusterItemDecoration` hook is unavailable to non-library renderers).
+ * [DefaultClusterRenderer] that assigns the pre-baked [BitmapDescriptor]s (rendered in the maps compose scope) to
+ * non-clustered item markers, and exposes the set of currently-unclustered items so the caller can decorate them (e.g.
+ * precision circles). Cluster bubbles keep the library's default rendering.
*/
-private class NodeChipClusterRenderer(
+private class NodeClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<NodeClusterItem>,
- private val density: Float,
- private val fontScale: Float,
+ private val iconProvider: (Int) -> BitmapDescriptor?,
) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
- val unclusteredItems: MutableState<Set<NodeClusterItem>> = mutableStateOf(emptySet())
+ val unclusteredItems = mutableStateOf<Set<NodeClusterItem>>(emptySet())
- // Called on a background render thread — building the descriptor here keeps marker rasterization off the
- // main thread, and BitmapDescriptorFactory is safe once the SDK is initialized (the live map guarantees it).
- override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
- markerOptions
- .icon(buildNodeChipDescriptor(item.node, density, fontScale))
- .anchor(CHIP_ANCHOR_U, CHIP_ANCHOR_V)
- .zIndex(item.getZIndex())
- }
-
- override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
- marker.setIcon(buildNodeChipDescriptor(item.node, density, fontScale))
- marker.zIndex = item.getZIndex()
+ init {
+ minClusterSize = MIN_CLUSTER_SIZE
}
override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
super.onClustersChanged(clusters)
unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet()
}
+
+ override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
+ // super sets title/snippet from the ClusterItem, which drives the native info window.
+ super.onBeforeClusterItemRendered(item, markerOptions)
+ iconProvider(item.node.num)?.let { markerOptions.icon(it) }
+ markerOptions.zIndex(item.getZIndex())
+ }
+
+ override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
+ // super keeps title/snippet (and the open info window) in sync.
+ super.onClusterItemUpdated(item, marker)
+ iconProvider(item.node.num)?.let { marker.setIcon(it) }
+ marker.zIndex = item.getZIndex()
+ }
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index ed1044fc2e..227805ebdb 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -16,14 +16,22 @@
*/
package org.meshtastic.app.map.component
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
+import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
@@ -31,6 +39,7 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
+@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
@@ -56,7 +65,11 @@ fun WaypointMarkers(
}
val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
- val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
+ val emojiText = convertIntToEmoji(iconCodePoint)
+ val icon =
+ rememberComposeBitmapDescriptor(iconCodePoint) {
+ Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
+ }
Marker(
state = markerState,

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
index 6dbc0510ca..9930820676 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/component/InlineMap.kt
@@ -27,15 +27,17 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
-import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.MapsComposeExperimentalApi
+import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
-import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.core.model.Node
+import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
+@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
@@ -77,11 +79,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeWidth = 2f,
)
}
- // Build the marker icon inside the map content: by the time this composes, the GoogleMap has created its
- // MapView and initialized the Maps SDK, so BitmapDescriptorFactory is ready. Building it before the map
- // (outside this lambda) is what crashed the inline map in #5702 / #5709.
- val markerIcon = rememberNodeChipDescriptor(node)
- Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
+ MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) { NodeChip(node = node) }
}
}
}

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6d378d6af8..713b34fc29 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -178,9 +178,7 @@ maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", ve
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
-# No version pin: maps-compose (via maps-ktx) already pulls the play-services-maps version it was built
-# against. Pinning here risks forcing a version maps-compose was not tested with — let the library drive it.
-play-services-maps = { module = "com.google.android.gms:play-services-maps" }
+play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }
qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode-kotlin" }

Served by rngit 1.5.0 - Generated in 0.45s